最近在做折线图(echarts)相关的需求,有遇到应用卡顿的情况,分析了卡顿原因后,发现在生成折线图的数据时耗时较大(从对象获取指定数据/数组过滤/排序/裁减),决定用 web worker 来做一些曲线绘制前的数据处理工作
Web Worker
js 是单线程执行的,但浏览器为其提供了多线程的能力 - web worker,借助 web worker 可以并行主线程做一些数据解析或计算的工作。但有一定的局限性,比如不能操作 DOM、访问 window、document 对象等、无法读取本地文件等。然后也会有额外的问题,比如内存占用、通信损耗
所以使用 web worker 并非一定会提升应用性能,需要分析当前性能瓶颈主要是卡在哪里,对症下药。解决性能问题往往需要多种方法来协调,大部分情况下解决关键因素即可,比如数据解析、计算、某个 long task 或者渲染耗时
分类:
- Dedicated Worker
- SharedWorker
- ServiceWorker
其他 Worker 自行了解,下面着重讲 Dedicated Worker
,以下简称 Worker
单页面应用使用 Worker
以 react 为例,如果是基于最新的 CRA 脚手架搭建的项目,即基于 webpack5 的项目,可以比较便捷地生成 Worker
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.postMessage({
name: "Lucas",
});
worker.onmessage = ({ data: { name } }) => {
console.log(name);
};
import.meta
是一个内置在 ES 模块内部的对象,import.meta.url
表示一个模块在浏览器和 Node.js
的绝对路径。该特性属于 es2020 的一部分,webpack5 才支持
如果是基于 webpack4 的项目,那需要借助其他方法了
第一种比较简单粗暴,可以把 worker.js
固定放在 public
文件夹下,默认打包后 public
下的文件是固定放在根路径下,可以通过 http://localhost:3000/worker.js
找到
第二种是推荐做法,引入 worker-loader
- 修改项目 webpack 配置。如果是 CRA 创建的项目,需要借助
react-app-rewired
扩展配置或者直接 eject 导出项目的 webpack 配置进行修改(不推荐)。改动内容大致如下,增加一个针对 worker 脚本的 loader 处理流程
module.exports = {
module: {
rules: [
{
// 以 .worker.js 结尾的文件将被 worker-loader 加载
test: /\.worker\.(c|m)?js$/i,
use: {
loader: "worker-loader",
},
},
],
},
};
CRA 项目中引入
const { override, addWebpackModuleRule } = require("customize-cra");
module.exports = {
webpack: override(
addWebpackModuleRule({
test: /\.worker\.(c|m)?js$/i,
use: [
{
loader: "worker-loader",
},
],
})
),
};
使用方法很简单,直接 import
导入并实例化
// main.ts
import Worker from "./test.worker.ts";
const worker = new Worker();
worker.onmessage = (event) => {
const data = event.data;
};
worker.postMessage({ data: "from main" });
worker.onerror();
// 不用的话可以关闭,节省内存
worker.terminate();
// test.worker.ts
declare const self: any;
export default {} as typeof Worker & { new (): Worker };
self.onmessage = (event) => {
const data = event.data;
}
self.postMessage({ data: "from worker" });
self.onerror();
self.close();
为了保证 worker 中的代码被 babel 转译,可以让 babel-loader
在 worker-loader
之前执行。ts-loader
同理
为什么不能直接 import 引入?
好问题..如果是直接 import 导入,那肯定是需要将它转成脚本路径,比如下面
import workPath from "./worker.js";
const worker = new Worker(workPath);
同样也是需要借助特定的 loader,类似于 file-loader
。至于 worker-loader
则是将new Worker(workPath)
的步骤内置到 loader 处理流程了,并导出一个函数,外面直接使用该函数即可创建指定的 Worker
worker-loader 是咋工作的?
其实原理不难,主要就是俩个步骤:
- webpack 构建过程匹配到 worker 脚本(xx.worker.js)
- 将文件名和源代码传入 worker-loader 处理函数中,主要输出以下内容
module.exports = function () {
return new Worker(__webpack_public_path_ + "123abc.worker.js");
};
loader inline 模式输出内容不太一样,参考 worker-loader 源码
第三种不推荐,是将 worker.js
的主函数转化为 blobUrl
导出,供主线程引用。该方法的好处是可以动态创建 worker
// worker.js
const contentCode = function () {} // worker 脚本主函数
const blob = new Blob([contentCode.toString()], {type: 'text/javascript'});
export {url: URL.createObjectURL(blob)}
// main.js
import { url } from './worker.js'
const worker = new Worker(url);
Worker 通信
- 拷贝通信(
Structured Clone
),即postMessage
,对于复杂对象,可能有人觉得先序列化成字符串再拷贝通信效率会更高点,答案是不确定的。有人做了详细的对比,可以参考 https://dassur.ma/things/is-postmessage-slow/ - 转移内存(
Transfer Memory
)。只支持Transferable Objects
,有数据独占和数据类型的限制 - 共享内存(
SharedArrayBuffer
)。浏览器兼容性很差,继续观察…
transfer 可转移对象是 ArrayBuffer、MessagePort、ImageBitmap 等二进制数据。Worker 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这使得主线程可以快速把数据交给 Worker,对于影像、声音处理等复杂计算就很有帮助
Worker 使用场景
- 数据预取和预解析(本文开头提到的场景)
- 视频解码:一般的视频网站 以优酷为例,当我们开始播放优酷视频的时候,就能看到它会调用 Worker,解码的代码应该写在 Worker 里面
- 复杂文件解析。需要大量计算的网站 比如 imgcook 这个网站,它能在前端解析 sketch 文件,这部分解析的逻辑就写在 Worker 里
- 拼写检查
- 数据加密
- 光线追踪…
其实主要都是为了不阻塞页面 ui,提高用户体验,但当通信比较耗时、计算不复杂时,这种时候用 Worker 就有些得不偿失了。当然,以上大部分场景都还只是在一些资料中看到,暂时还没实际项目可实践到,以后有机会再进一步写写 Worker 相关的一些文章吧